Apply function to points within circular neighborhood - #941
Conversation
Kept neighborhood and dual additions
|
HI @ahijevyc Apologies for not getting to this PR earlier. Looking at the implementation here, it looks great. It does however bring to light a possible need for us to consider a better, more streamlined, approach to handling these types of groupings and then applying some function on the result. I mention this because of our Topological Aggregations. For this family of functions, we have distinct methods (i.e. Very generally speaking, these functions essentially:
I wonder if this would be a good opportunity to extend the inherited I'm not sure of calling these approaches "kernels" is appropriate, but for the sake of this example, we could provide spatial kernels the user could pass into # radial neighborhood of r=0.25
uxds['t2m'].groupby(kernel=ux.BoundingCircle(r=0.25)).mean()
# 2 deg by 2 deg bounding box
uxds['t2m'].groupby(kernel=ux.BoundingBox(dlon=2, dlat=2))
# group the nodes that surround each face and find the maximum
uxds['node_centered_var'].groupby(kernel=ux.FaceNode()).max()
# this is equivalent to the following in the current release
uxds['node_centered_var'].topological_max(destination='face')I'll ping @aaronzedwick and @erogluorhan for their thoughts on this. I personally really like the design above and think that it aligns well with the overall design. |
That is interesting. You suggesting changing the way we do aggregations entirely? Then this would affect the reduction PR I am working on then. Perhaps this PR could implement that change if you wish. I am fine with this, if you want to, it sounds like it would be intuitive. |
We should be changing the API for it, since the current one is not sustainable if we plan to implement many times of spatial grouping methods. The underlying functionality would remain mostly unchanged.
If we do decide to do this, I'll open up a separate PR starting with the topological aggregations. |
So would the reductions PR be obsolete? |
No. The underlying implementation would remain the same, since we would still need those implemented. This would just provide a different interface for it, with a more "Xarray-like" interface. |
Ah, okay, I see. That makes sense, thanks for the clarification! |
|
@ahijevyc — since GitHub blocks adding the PR author as a reviewer, could you take a look when you get a chance? Key changes since your last review round: Bug fixes:
API compliance / xarray parity:
Docs:
Happy to discuss further or adjust anything before merge! |
Philip is no longer on the project. Dismissing stale review — all concerns have been addressed in subsequent commits.
ahijevyc
left a comment
There was a problem hiding this comment.
Thanks for carrying this across the finish line! I just had a comment or two about the assertion that NaN could be returned for a coarse grid and small radius. I don't think this could occur in practice, as the filter should at least return the values at the original grid points even if the radius is zero. And grid.neighbors.BallTree.query_radius already makes sure the radius is not negative.
Neighborhoods are never empty: query_radius rejects a negative radius and every element is its own neighbor at distance 0, so r=0 returns the original values. Reword the code comment and user-guide section accordingly, keeping np.full(NaN) allocation only as a defensive measure over np.empty. Also raise DataCenteringError instead of a bare ValueError for non-grid-mapped data, matching the error types introduced in uxarray/errors.py, and drop a redundant self-import in UxDataArray.isel.
- Include distance_metric in the get_ball_tree/get_kd_tree cache invalidation check. Only coordinates and coordinate_system were compared, so requesting a different metric silently returned a tree built with the original one. - Request a spherical/haversine tree explicitly in _neighborhood_filter. It previously read coordinate_system back off whatever tree was cached, so a cartesian tree from an earlier call made r a chord length instead of the documented great-circle degrees. - Raise an explanatory TypeError when func does not accept an axis keyword, instead of surfacing a raw NumPy message. - Document radius units, overlapping neighborhoods, and eager evaluation of dask-backed input in both public docstrings. - Restore the original _copy deep default; changing it was unrelated to this feature and affects every UxDataArray copy. Adds coverage for tree cache invalidation, the spherical-tree guarantee, the func-without-axis error, and dask input.
erogluorhan
left a comment
There was a problem hiding this comment.
Overall this looks great, but I have a couple concerns in the low-level implementation for which @cmdupuis3 could be helpful as well (added as a reviewer). See below
| func: Callable = np.mean, | ||
| r: float = 1.0, | ||
| ) -> UxDataArray: | ||
| """Apply a neighborhood filter, replacing the value at each grid |
There was a problem hiding this comment.
Might use a little rephrasing: "replace" might be misleading here since function returns a new data array and actually doesn't change the original data?
|
|
||
| # Apply func along the last (grid) axis only, so any extra leading | ||
| # dimensions (e.g. time) are preserved rather than being collapsed. | ||
| for i, idx in enumerate(neighbor_indices): |
There was a problem hiding this comment.
This will iterate through every single geometric element, e.g. faces, right? It looks very costly for km-scale data. Was there any consideration of vectorization for this?
cc: @cmdupuis3 for your thoughts
There was a problem hiding this comment.
Something you could do is make a boolean mask out of neighbor_indices as keys, then mask it to neighbor_masked, then you could delete the whole conditional/try/except block and iterate over only the valid entries. If you really need the full-scale destination_data, you can use the mask to reshape the output.
| for i, idx in enumerate(neighbor_indices): | ||
| if len(idx): | ||
| try: | ||
| destination_data[..., i] = func(data[..., idx], axis=-1) |
There was a problem hiding this comment.
From what I can tell on this line, every single iteration will trigger rebuilding and executing the dask task graph from scratch because this line returns a lazy dask scalar when data is dask-backed and assigning that into a numpy array index.
I believe this could be avoided if uxdataarray.py/neighborhood_filter did an upfront .compute() for data (and actually that function already claims "lazy (dask-backed) data is computed eagerly and the result is always NumPy-backed").
cc: @cmdupuis3 you dealt with bundled .compute()s through xarray API in #1560, it might not directly related to here, but you may still have some thoughts.
There was a problem hiding this comment.
Looks like it. Just at a glance, this block seems like it's basically a ufunc implementation.
|
I'm working on a chunk-friendly optimized version based on gufuncs, I'll add my own PR off this one and y'all can merge it. The one caveat is that we'd have to hand-enumerate the specific reductions we want to be able to run optimally, I think otherwise we can offer a fallback option but the performance would suffer. I don't think that would happen that often in practice though. |
Makes sense, thanks for finding this out, that code would lose out-of-core/parallel benefits; most of the times this would be one of the steps in the bigger pipeline and if others are nice dask ops that would be a good win. I'll keep an eye on the followup to this. |
Follows the gufunc work with the API changes it made possible.
Kernel factory. The buffered kernels (median and friends) share one gather
loop, with the reduction supplied as a numba-compilable callable. numba
supports the NumPy reductions in nopython mode, so np.median is used directly
rather than reimplemented -- it partitions instead of fully sorting, making it
1.6x faster than the hand-written sort it replaces, as well as shorter. Adds
ptp, std, var, quantile and percentile; the parameterized ones carry their
argument as a scalar core dimension so q and ddof vary per call without
recompiling.
Named reductions. `func` now takes a name ("mean", "quantile", ...) with
parameters as ordinary keyword arguments. The previous `axis=-1` contract
could not be jitted -- numba rejects the axis kwarg on mean/median/percentile
-- and dispatching on a function object cannot see through functools.partial,
so `partial(np.percentile, q=90)`, the example in our own docstring, could
never reach a kernel. A name always can. Callables remain accepted on the old
contract as an escape hatch, and np.mean and friends still map to their
kernels so existing code keeps the fast path.
Neighborhoods. Finding the neighbors costs more than reducing over them --
after the kernel work it is ~95% of a call -- and it was repeated on every
call, including once per variable in the dataset path. Grid.neighborhoods()
does the search once and returns an object to reduce over repeatedly: 3.5x for
four reductions at one radius on a 196k-face grid, and a dataset filter now
costs one query per grid location rather than one per variable (8 variables:
1.74s -> 0.23s).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- numba guvectorize kernels for compiled parallel reductions - Neighborhoods object: one BallTree query reused across variables/reductions - Named reduction API: func='mean', func='percentile', q=90, etc.
|
pre-commit.ci autofix |
Apply a neighborhood filter within a circular radius r to a UxDataset or UxDataArray.
Closes #930
Overview
This is kind of like
uxarray.UxDataArray.inverse_distance_weighted_remap, but the neighborhood is defined by distance, not a number of nearest neighbors. This is ideally suited for a variable resolution mesh, in which a constant of neighbors doesn't have a constant sized neighborhood. Another difference is that this neighborhood filter does not weight data by inverse distance.Just like
uxarray.UxDataArray.subset.bounding_circlethis function usesball_tree.query_radiusto select grid elements in a circular neighborhood, but this function finds the neighborhood for all elements in grid, not just one center_coordinate.The filter function
funcmay be a user-defined function, but usesnp.meanby default. It could bemin,max,np.median. It can even use functions that require additional arguments, likenp.percentileif you supply the argument(s) withfunctools.partial(see below)Expected Usage
PR Checklist
General
Testing
Documentation
_);_neighborhood_filteris internal touxarray/grid/neighbors.pydocs/api.rst(the split user/internal api files no longer exist)Examples
docs/examples/folderdocs/examples.rsttoctreedocs/gallery.ymlwith appropriate thumbnail photo indocs/_static/thumbnails/